{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#Kivy网络编程"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "前面我们尝试过单一平台Android的Kivy开发，通过原生的底层API来实现。下面我们探索另外一种天生具有跨平台能力的工具来做app——网络。在这一章，我们做一个聊天app，类似于QQ，但是简单版。\n",
    "\n",
    "<!-- TEASER_END-->"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "当然这个应用不能替代QQ，不过支持类似QQ群聊天功能，方便临时性的会议建群聊天。\n",
    "\n",
    "为了简化，我们不实现认证授权功能，这是为彼此都很熟悉的用户设计的。如果你想让app更安全，自己可以实现一下。\n",
    "\n",
    "为了在服务器端支持最大兼容性，这个app用**Telnet**收发消息。虽然不是Kivy的app的图形用户界面，Telnet可以在Windows 95和MS-DOS完美运行。\n",
    ">更严谨的考证一下，其实Telnet在1973年就标准化了，因此它甚至可以在8086 CPU上运行。Windows 95和MS-DOS相比之下已经很新了。\n",
    "\n",
    "本章教学大纲如下：\n",
    "\n",
    "- 用Python的Twisted框架实现服务器端。\n",
    "- 在不同的抽象层面上开发两个客户端，一个是通过套接字实现命令行程序，一个是通过Twisted的事件驱动客户端实现的程序\n",
    "- 用Kivy的`ScreenManager`更容易的实现UI\n",
    "- 做一个`ScrollView`容器实现消息的无限长度\n",
    "\n",
    "我们的应用将使用中心化的客户端-服务器架构，很多网站和应用都用这种主流的互联网方法论。与去中心化的P2P网络相比，你很快会发现这种方法是多么的容易。\n",
    ">这里没有区分互联网与局域网（local area network，LAN），但是两者在底层上没啥关系。但是，如果你要把应用发布到应用商店，你还需要准备很多其他的内容，比如设置一个安全网络服务器，配置防火墙来保证你的代码可以扩展到多核处理器和其他设备。实际上这并没有多可怕，但是仍然需要一些努力。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "##实现聊天服务器"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "开始写客户端之前我们先把服务器端做出来。我们用**Twisted**框架来提高效率，通过Python来实现，这样可以避开许多常见的、底层的网络任务。\n",
    ">**兼容性提示**\n",
    ">Twisted目前仍然不能很好的不支持Python3，所以这里得使用Python2.7。其实2to3很容易移植，因为没多少不兼容的设计方式。（不过，我们完全忽视了Unicode相关的问题，因为Python2和Python3的处理方式不同。如果是中文，还是Python3方便。）\n",
    "\n",
    "Twisted是一个事件驱动的底层的服务器框架，不像**Node.js**（实际上，Node.js的设计受到Twisted的影响）。与Kivy很类似，事件驱动的架构意味着我们不需要把代码构建一个无限循环，相反我们只要为app绑定大量的事件监听器就行。许多底层的网络处理，都可以通过Twisted方便的实现。\n",
    ">和其他Python包一样，用pip就可以安装Twisted：\n",
    "\n",
    ">**pip install -U twisted**\n",
    "\n",
    ">有两点要注意：\n",
    "- 安装的时候得取得管理员权限（sudo）\n",
    "- 如果没装pip，easy_install也可以，Python2.7.9和Python3.4版本已经自带pip了"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###协议定义"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "现在我们来看下聊天服务器即将使用的通信协议，因为我们的app并不复杂，所以我们不用XMPP那样主流的、功能丰富的程序，我们自己实现一个简单的就行。\n",
    "\n",
    "我们实现的协议层就是两条信息在客户端和服务器传递——连接服务器（进入聊天室），对其他人说话。服务器会反馈客户端传递的每件事情；服务器自己不会产生任何事件。\n",
    "\n",
    "我们的协议执行原文传递，和很多其他的应用层如HTTP协议一样。这样做很合理，因为调试和实现起来都很方便。字符串协议比二进制码协议更具扩展性和前瞻性（future-proof）。缺点就是包压缩率底，占用资源多；二进制协议可以更紧凑。不过在这个app讨论这样不太重要，也可以通过压缩技术缓解这个不足（这就是为啥很多服务器都是HTTP协议）。\n",
    "\n",
    "现在，让我们梳理一下每条消息在应用协议的思路：\n",
    "\n",
    "- 连接到服务器的信息只有用户现在是否在聊天室，每次就用一个单词`CONNECT`来检测。这个信息不需要参数化，直接用单词。\n",
    "- 在聊天室说话更有趣。有两个参数：用户名和文字信息。让我们把格式定义为`A:B`，`A`就是用户名（我们要求用户名不能包含分号`:`字符）。\n",
    "\n",
    "根据这个思路，我们写出下面的算法（伪代码）："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "if ':' not in message\n",
    "    then\n",
    "        // it's a CONNECT message\n",
    "        add this connection to user list\n",
    "    else\n",
    "        // it's a chat message\n",
    "        nickname, text := message.split on ':'\n",
    "        for each user in user list\n",
    "            if not the same user:\n",
    "                send \"{nickname} said: {text}\""
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "对同一个用户的测试就是把向用户自己传递的信息去掉。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###服务器代码"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "有了Twisted的帮助，我们的伪代码可以很直接的写出Python代码。下面就是我们应用的`server.py`文件："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "from twisted.internet import protocol, reactor\n",
    "\n",
    "transports = set()\n",
    "\n",
    "class Chat(protocol.Protocol):\n",
    "    def dataReceived(self, data):\n",
    "        transports.add(self.transport)\n",
    "        \n",
    "        if ':' not in data:\n",
    "            return\n",
    "        user, msg = data.split(':', 1)\n",
    "        \n",
    "        for t in transports:\n",
    "            if t is not self.transport:\n",
    "                t.write('{0} says: {1}'.format(user, msg))\n",
    "\n",
    "class ChatFactory(protocol.Factory):\n",
    "    def buildProtocol(self, addr):\n",
    "        return Chat()\n",
    "\n",
    "reactor.listenTCP(9096, ChatFactory())\n",
    "reactor.run()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###设计原理"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "下面的操作流程可以帮助你理解上面的代码：\n",
    "\n",
    "- 最后一行`reactor.run()`开启`ChatFactory`服务器监听9096端口\n",
    "- 当服务器收到请求，就调用`dataReceived()`响应\n",
    "- `dataReceived()`方法就是前面伪代码的实现，会把消息发送给其他用户\n",
    "\n",
    "每个到客户端的连接构成集合`transports`。我们无条件的把当前的传递`self.transport`加入集合。\n",
    "\n",
    "然后就是算法的实现。最后，聊天室内除了发消息的每个用户都会收到提示，< **username**>说了<**message text**>。\n",
    ">注意我们并没有用`CONNECT`来检查连接的信息。这是按照1980年Jon Postel在TCP说明书里面提出的*网络稳健性（network robustness）*原则设计的：保守的发送，自由的接收。\n",
    ">另外通过简化代码，我们还获得了更好的兼容性。在未来要发布新版本客户端时，如果我们给协议增加一个新消息，假设叫`WHARRGARBL`消息，名字看着就很酷。没有崩溃是因为虽然收到来一个格式错误的消息（这是由于版本不匹配），老版本的服务器会直接忽略这些消息继续工作。\n",
    ">这些具体的版本兼容性问题可以通过许多策略来纠正。但是，还有更多来自网络尤其是公网的难题，包括用户恶意攻击拖垮服务器等等。所以，实际中并没有服务器非常稳定这种可能。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###服务器测试"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "直接运行Python文件就可以测试服务器：\n",
    "```sh\n",
    "python server.py\n",
    "```\n",
    "这个命令的结果不会直接看到。服务器开始运行，等待客户请求。但是，我们还没客户端程序。那怎么测试呢？\n",
    "\n",
    "这种先有服务器还是先有客户端的经典问题有很多方法可以解决——向服务器发信息，然后显示服务器反馈的信息。\n",
    "\n",
    "处理字符串协议服务器的一个标准化工具就是Telnet，一般都是命令行，没有图像界面。很多操作系统都带有Telnet。在Windows系统中，打开“控制面板|程序和功能|启动或关闭Windows功能”就可以找到Telnet。\n",
    "\n",
    "![Telnet](kbpic/4.1Telnet.png)\n",
    "\n",
    "Telnet有两个参数：服务器地址和端口。为了连接到Telnet，你需要先启动`server.py`，然后再打开命令行输入：\n",
    "```sh\n",
    "telnet 127.0.0.1 9096\n",
    "```\n",
    "\n",
    "另外，你也可以用`localhost`代替`127.0.0.1`，在`hosts`文件中是默认设置。\n",
    "\n",
    "现在就可以测试服务器了。你可以根据前面设计流程，向服务器发送内容进行实现测试：\n",
    "\n",
    "```\n",
    "CONNECT\n",
    "User A:Hello, world!\n",
    "```\n",
    "\n",
    "没有出现任何反馈，因为我们没有让服务器向原作者反馈信息。因此，我们需要打开另外一个命令行，然后以一个新的用户登录。就可以看到`User A`发送的信息了。如下图所示：\n",
    "\n",
    "![servertest](kbpic/4.2servertest.png)\n",
    "\n",
    ">如果你没法儿正常测试Telnet也甭灰心，这不影响咱们app的顺利完成。\n",
    ">如果用Windows的话，给一点小建议：最好给电脑装个Mac OS或Linux，双系统更适合研发工作，推荐使用虚拟机，切换方便。\n",
    "\n",
    "这样，我们就知道服务器可以正常工作了。现在我们来做客户端系统的GUI。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "##屏幕管理器"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "现在我们用一个新概念来设计UI，叫屏幕管理器。用来设计我们的app合适。一共是两个UI状态：\n",
    "\n",
    "- 登录界面：包括服务器地址、用户名、登录按钮\n",
    "![uilogin](kbpic/4.3uilogin.png)\n",
    "- 聊天界面：包括信息显示、信息输入、发送信息按钮和端口服务器按钮\n",
    "![uilogin](kbpic/4.4uichat.png)\n",
    "\n",
    "从理论上说，我们的app界面就是这样。这种UI分离的设计方法涉及到，对不同UI状态里可见的与隐藏的控件的管理。这样可以快速的组合一堆部件，而不要写任何代码。\n",
    "\n",
    "Kivy为我们提供`ScreenManager`来实现UI设计。另外，`ScreenManager`还提供了屏幕切换的动态过程，以及大量的内置转换方式。可以完全通过Kivy语言来实现，不需要任何Python代码。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "下面让我们来实现，首先建立`chat.kv`文件：\n",
    "```yaml\n",
    "ScreenManager:\n",
    "    Screen:\n",
    "        name: 'login'\n",
    "\n",
    "        BoxLayout:\n",
    "            # other UI controls -- not shown\n",
    "            Button:\n",
    "                text: 'Connect'\n",
    "                on_press: root.current = 'chatroom'\n",
    "    \n",
    "    Screen:\n",
    "        name: 'chatroom'\n",
    "        \n",
    "        BoxLayout:\n",
    "            # other UI controls -- not shown\n",
    "            Button:\n",
    "                text: 'Disconnect'\n",
    "                on_press: root.current = 'login'\n",
    "```\n",
    "这是程序的基本结构：我们建立`ScreenManager`根部件，并为每个状态建立一个`Screen`容器。容器里面有上面看到的布局、按钮。后面我们会继续完善。\n",
    "\n",
    "代码里面看到还包括`Screen`的按钮。为了切换应用的状态，我们还需要设置`ScreenManager`的`current`属性。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "###屏幕切换动作"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "前面提到，屏幕可以通过切换动作的动态显示。Kivy提供了许多切换功能，在`kivy.uix.screenmanager`包里面："
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "| Transition class name | Visual effect |\n",
    "|::|::|\n",
    "| `NoTransition` | 没有动画，直接显示屏幕 |\n",
    "| `SwapTransition` | 滑到下一屏幕，用上、下、左（默认）、右。 |\n",
    "| `SwapTransition` | iOS屏幕切换效果 |\n",
    "|`FadeTransition` | 褪色方式切换 |\n",
    "|`WipeTransition` | 用1px的遮挡实现平滑的定向切换 |\n",
    "|`FallOutTransition` | 将旧屏幕缩小到屏幕中间，渐渐透明，再出现新屏幕 |\n",
    "|`RiseInTransition` | 与`FallOutTransition`完全相反，新屏幕从中间出现，放大直到遮住旧屏幕 |"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "在`.lv`文件里面设置这些切换动作时需要注意一旦：切换需要手动导入。\n",
    "```yaml\n",
    "#:import RiseInTransition kivy.uix.screenmanager.RiseInTransition\n",
    "```\n",
    "现在，你就可以配置`ScreenManager`了。注意这些动作都是Python类的实例，所以后面要加括号：\n",
    "```yaml\n",
    "ScreenManager:\n",
    "    transition: RiseInTransition()\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "###登录界面布局"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "登录界面布局和前一章的录音app类似：用一个`GridLayout`把各个元素按照网格排列。\n",
    "\n",
    "还没用过的部件就是文本框`TextInput`。Kivy的文本框和按钮基本完全一样，区别就是可以输入文字。默认情况下是多行显示，因为在聊天app里面多行少见（如微信、QQ），所以要设置`multiline`为`False`。\n",
    "\n",
    "在无键盘设备上运行时，Kivy会调用虚拟键盘，和原生应用一样。下面的代码就是登录界面布局：\n",
    "\n",
    "```yaml\n",
    "Screen:\n",
    "    name: 'login'\n",
    "    BoxLayout:\n",
    "        orientation: 'vertical'\n",
    "        GridLayout:\n",
    "            Label:\n",
    "                text: 'Server:'\n",
    "            TextInput:\n",
    "                id: server\n",
    "                text: '127.0.0.1'\n",
    "            Label:\n",
    "                text: 'Nickname:'\n",
    "            TextInput:\n",
    "                id: nickname\n",
    "                text: 'Kivy'\n",
    "        Button:\n",
    "            text: 'Connect'\n",
    "            on_press: root.current = 'chatroom'\n",
    "```\n",
    "\n",
    "这里，我们增加了`Server`和`Nickname`两个文本框，对应的标签和按钮。按钮的事件handler还有没有任何功能，后面会实现。\n",
    "\n",
    "可以让单行的`TextInput`更好看点，我们让文本框里面的文字垂直居中：\n",
    "```yaml\n",
    "<TextInput>:\n",
    "    multiline: False\n",
    "    padding: [10, 0.5 * (self.height – self.line_height)]\n",
    "```\n",
    "`padding`属性设置了左右的边距都是10，上面和下面的边距是文本框高度与一行文本高度之差的一半。下面就是效果图，可前面app的界面类似：\n",
    "\n",
    "![loginscreen](kbpic/4.5loginscreen.png)\n",
    "\n",
    "现在我们可以写代码来连接服务器了，不过之前我们先把聊天主界面做出来。这样我们就可以直接在上面测试了。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "###聊天界面布局"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "聊天界面布局使用了`ScrollView`实现对话长度的切换，因为是第一次说这个空间，下面会详细介绍：\n",
    "```yaml\n",
    "<ChatLabel@Label>:\n",
    "    text_size: (self.width, None) # Step 1\n",
    "    halign: 'left'\n",
    "    valign: 'top'\n",
    "    size_hint: (1, None) # Step 2\n",
    "    height: self.texture_size[1] # Step 3\n",
    "ScrollView:\n",
    "    ChatLabel:\n",
    "        text: 'Insert very long text with line\\nbreaks'\n",
    "```\n",
    "\n",
    "文字满屏之后，滚动条会出现，类似于在Android和iOS里面看到的。具体的设计流程如下：\n",
    "\n",
    "1. 我们用`text_size`属性设置`Label`子类的宽度，把第二个参数高度设置成`None`，允许无限长度\n",
    "2. 把`size_hint`属性第二个参数设置为`None`，允许无限长度，迫使其高度与它的容器`ScrollView`独立。但是，它的长度会受到上一层的元素的限制，因此不会滚动。\n",
    "3. 设置部件的高度等于`texture_size`属性高度（注意索引都从0开始，因此第二个元素是`texture_size[1]`）。这就迫使`ChatLabel`比包含它的`ScrollView`部件大\n",
    "4. 当`ScrollView`部件发现它的子部件比它的空间大时，滚动条就出现了。这和手机上看到的一样，桌面系统也支持鼠标滚轮操作。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "####滚动模式"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "你还可以为`ScrollView`设置滚动的效果来模仿原生平台的特点（不过和原生的效果还是有差别）。可以实现的效果如下：\n",
    "\n",
    "- `ScrollEffect`：当触及最底部的时候可以停止滚动，类似于桌面应用常见的功能\n",
    "- `DampedScrollEffect`：这是默认的效果，类似于iOS，非常适合移动设备\n",
    "- `OpacityScrollEffect`：与`DampedScrollEffect`效果类似，滚动时增加了滚动条的透明度，不会遮挡内容\n",
    "\n",
    "要使用这些效果，从`kivy.effects`模块导入效果，然后配置到`ScrollView.effect_cls`属性，与`ScreenManager`切换效果类似。本章app不改，就用默认效果，可以自行设置。\n",
    "\n",
    "把上述内容综合起来，`chat.kv`文件代码如下：\n",
    "\n",
    "```yaml\n",
    "Screen:\n",
    "    name: 'chatroom'\n",
    "    BoxLayout:\n",
    "        orientation: 'vertical'\n",
    "        Button:\n",
    "            text: 'Disconnect'\n",
    "            on_press: root.current = 'login'\n",
    "        ScrollView:\n",
    "            ChatLabel:\n",
    "                id: chat_logs\n",
    "                text: 'User says: foo\\nUser says: bar'\n",
    "        BoxLayout:\n",
    "            height: 90\n",
    "            orientation: 'horizontal'\n",
    "            padding: 0\n",
    "            size_hint: (1, None)\n",
    "            TextInput:\n",
    "                id: message\n",
    "            Button:\n",
    "                text: 'Send'\n",
    "                size_hint: (0.3, 1)\n",
    "```\n",
    "\n",
    "最后一行的`size_hint`属性设置了按钮的水平比例为0.3，默认的是1。这就会让发送按钮比文本框小。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "为了把消息的背景色设置成白色的，我们可以这样：\n",
    "```yaml\n",
    "<ScrollView>:\n",
    "    canvas.before:\n",
    "        Color:\n",
    "            rgb: 1, 1, 1\n",
    "        Rectangle:\n",
    "            pos: self.pos\n",
    "            size: self.size\n",
    "```\n",
    "\n",
    "这就在其他元素绘制之前为`ScrollView`铺上了白色。别忘记了调整一下`<ChatLabel>`类，把背景色设置成浅色背景：\n",
    "```yaml\n",
    "#:import C kivy.utils.get_color_from_hex\n",
    "\n",
    "<ChatLabel@Label>:\n",
    "    color: C('#101010')\n",
    "```\n",
    "效果图如下：\n",
    "![Chatroomscreen](kbpic/4.6Chatroomscreen.png)\n",
    "\n",
    "这里Disconnect按钮就是断开网络连接。这是下一章的内容，到时候你会发现，用Python实现简单网络程序的难度，与用Kivy建立简单的UI没啥区别。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "##连接网络"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "下面我们连接服务器来收发消息，并显示给用户。\n",
    "\n",
    "首先，我们看一个纯Python实现的聊天客户端，用套接字就可以实现。不过还是推荐用高级工具，如Twisted；如果你对这类知识没有一点儿概念，可能有点小困难，坚持一下就会好，多试几次准明白。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "###一个简单Python客户端"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "下面我们将用`readline()`函数读取用户的消息，然后通过`print()`函数显示在命令行上。这和Telnet没啥区别——都是命令行界面显示消息——只是我们从底层的套接字开始做起。\n",
    "\n",
    "这需要一些标准模块：`socket`，`sys`（`sys.stdin`提供输入文件接口）和`select`模块等待消息出现。新建一个客户端文件`client.py`："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "import select, socket, sys"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "这个程序没有其他依赖关系；所有平台的Python都支持。\n",
    ">不过Windows里面的`select`，由于其代码实现方式不同，不能把文件描述器调整为套接字接受的样式。所以这个客户端就不能运行了，不过这个客户端我们最后也不会用，所以不要担心，如果你用的是Windows。\n",
    "\n",
    "现在，我们来连接服务然后用`CONNECT`对接："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "s.connect(('127.0.0.1', 9096))\n",
    "s.send('CONNECT')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "然后就是等待两类消息，一类是标准输入（用户输入的文字），一类`s`套接字（服务器发送消息）。等待可以用`select.select()`实现："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "rlist = (sys.stdin, s)\n",
    "while True:\n",
    "    read, write, fail = select.select(rlist, (), ())\n",
    "    for sock in read:\n",
    "        if sock == s: # receive message from server\n",
    "            data = s.recv(4096)\n",
    "            print(data)\n",
    "        else: # send message entered by user\n",
    "            msg = sock.readline()\n",
    "            s.send(msg)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "然后，服务器根据收到的最新数据进行反馈，我们可以把服务器发送的信息显示到屏幕上，或者如果是用户发送的消息就发送到服务器。其实这就是Telnet做的事情，只是缺少错误异常检测部分。\n",
    "\n",
    "你会发现底层的模块实现客户端并没有我们想象的困难。但是相比高级模块如Twisted，原始套接字代码还是冗长的。但是这里显示了客户端工作的原理，其他任何高级工具都是这里实现的，高级工具也不过是通过底层的套接字实现，然后加上方便使用的API而已。\n",
    "\n",
    ">注意这里我们没有加异常检测部分，因为代码可能增加2-3倍，感兴趣的可以练习一下。\n",
    ">网络是非常容易出错的；比其他IO都脆弱。因此，如果你打算做一个类似于Skype那样的商业软件，你的代码里面将充斥异常检测和测试：比如丢包，防火墙问题等等。不论你的架构设计的多么好，网络服务想获得极高的可靠性很难。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "###用Twisted实现"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "纯Python代码写的客户端不太适合Kivy，原因就是主循环部分（`while True:`）。要让这个循环与Kivy的事件循环协同运作，还得做些事情。\n",
    "\n",
    "不过，Twisted的优势可以很好的弥补这一点，实现同样的网络模块可以同时作用于服务器和客户端，使得代码更统一。关键在于Twisted可以与Kivy的事件循环协同运作，首先让我们把Twisted相关模块导入："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "from kivy.support import install_twisted_reactor\n",
    "install_twisted_reactor()\n",
    "\n",
    "from twisted.internet import reactor, protocol"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "这段代码要放在`main.py`文件的最上面。下面我们用Twisted来实现："
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "####ChatClient和ChatClientFactory"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "用Twisted实现工作量很少，因为这个框架为网络相关的每一件事情都做了很好的接口，这些类通过简单的连接就可以完成工作。\n",
    "\n",
    "` ClientFactory`的子类`ChatClientFactory`可以在初始化阶段储存Kivy app的实例，这样我们就可以向它传递事件。代码如下："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "class ChatClientFactory(protocol.ClientFactory):\n",
    "    protocol = ChatClient\n",
    "    \n",
    "    def __init__(self, app):\n",
    "        self.app = app"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`ChatClient`类监听Twisted的`connectionMade`和`dataReceived`事件，然后发送到Kivy app："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "class ChatClient(protocol.Protocol):\n",
    "    def connectionMade(self):\n",
    "        self.transport.write('CONNECT')\n",
    "        self.factory.app.on_connect(self.transport)\n",
    "        \n",
    "    def dataReceived(self, data):\n",
    "        self.factory.app.on_message(data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "注意那个无所不在的`CONNECT`握手信号。\n",
    "\n",
    "这和前面的套接字写法很不同，是吧？而且和前面的`server.py`很像。但是，我们只是把事件传递给aoo对象，并没有处理事件。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###加入UI"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "要看到app的全貌，我们还要把UI也加进来。`chat.kv`文件如下：\n",
    "```yaml\n",
    "Button: # Connect button, found on login screen\n",
    "    text: 'Connect'\n",
    "    on_press: app.connect()\n",
    "Button: # Disconnect button, on chatroom screen\n",
    "    text: 'Disconnect'\n",
    "    on_press: app.disconnect()\n",
    "TextInput: # Message input, on chatroom screen\n",
    "    id: message\n",
    "    on_text_validate: app.send_msg()\n",
    "Button: # Message send button, on chatroom screen\n",
    "    text: 'Send'\n",
    "    on_press: app.send_msg()\n",
    "```\n",
    "\n",
    "注意按钮不会再切换屏幕了，相反它们调用`app`的方法，类似于`ChatClient`事件处理。\n",
    "\n",
    "完成这些之后，我们需要实现Kivy应用类里面的5个方法：两个是Twisted代码中的服务器生成事件（`on_connect`和`on_message`），三个是用户接口事件（`connect`，`disconnect`和`send_msg`）。这样才能让聊天app真正可以用。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###客户端的设计思路"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "当我们简单写一些程序运行逻辑：从`connect()`到`disconnect()`。\n",
    "\n",
    "在`connect()`方法里面，我们把**Server**和**Nickname**参数作为用户输入。**Nickname**参数被储存到`self.nick`，Twisted客户端连接到具体的服务器地址："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "class ChatApp(App):\n",
    "    def connect(self):\n",
    "        host = self.root.ids.server.text\n",
    "        self.nick = self.root.ids.nickname.text\n",
    "        reactor.connectTCP(host, 9096, ChatClientFactory(self))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "调用`ChatClient.connectionMade()`函数，把控件传递到`on_connect()`方法上。我们将用事件来储存`self.conn`连接，然后切换屏幕。前面提到过，按钮不再直接切换屏幕，而且通过事件handler实现："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "# From here on these are methods of the ChatApp class\n",
    "def on_connect(self, conn):\n",
    "    self.conn = conn\n",
    "    self.root.current = 'chatroom'"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "现在主要部分就是收发信息。很简单，就是从`TextInput`发信息，把`self.nick`加上，发送给服务器。最后把信息显示出来，并且清空`TextInput`。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "def send_msg(self):\n",
    "    msg = self.root.ids.message.text\n",
    "    self.conn.write('%s:%s' % (self.nick, msg))\n",
    "    self.root.ids.chat_logs.text += ('%s says: %s\\n' % (self.nick, msg))\n",
    "    self.root.ids.message.text = ''"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "接受消息更简单，因为我们不会保持这些内容，所有就是把消息显示到屏幕上："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "def on_message(self, msg):\n",
    "    self.root.ids.chat_logs.text += msg + '\\n'"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "最后一个方法就是`disconnect()`：关闭连接，清理所有内容回到初始界面。这样用户就可以重新连接其他服务器了。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "def disconnect(self):\n",
    "    if self.conn:\n",
    "        self.conn.loseConnection()\n",
    "        del self.conn\n",
    "    self.root.current = 'login'\n",
    "    self.root.ids.chat_logs.text = ''"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "这样程序就搞定了。\n",
    ">提示：\n",
    ">测试的时候，`server.py`文件应该持续运行，但是我们的app就不能终止连接了。最终结果就是app停留在登录界面，不再调用`on_connect()`，用户也不能到聊天室界面。\n",
    ">还有，在Android上面测试的时候，确定你的服务器IP地址，不是`127.0.0.1`，只要局域网设备才这样，在Android设备上不一样。可以用`ifconfig`查询（Windows上是`ipconfig`）。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###客户端交互"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "前面做的Telnet、两个客户端虽然实现方式不同，却可以通信，因为其底层的原理基本一致。\n",
    "\n",
    "类似于互联网的处理方式：只要你用HTTP协议，相关的服务器和客户端就可以交互：网页服务器、浏览器、搜索引擎等等。\n",
    "\n",
    "协议是更高级的API，与语言、系统无关，应该选一个流行的用。并不是每个网络开发者都熟悉微软2007年发布的Silverlight协议，但大家都知道1991年发布的HTTP。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "##增强视觉体验"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "现在app已经可以运行了，我们把聊天窗口改善一下。可以用Kivy的**BBCode**来修饰。\n",
    "\n",
    "让我们给每个用户加个颜色，这样方便用户区分所有人。我们同样使用**扁平化UI**的配色方式。\n",
    "\n",
    "当前用户发送的信息不会从服务器发给自己，是通过客户端代码添加到对话内容里的。所以，我们要把当前用户名加一个固定的颜色。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "colors = ['7F8C8D', 'C0392B', '2C3E50', '8E44AD', '27AE60']\n",
    "\n",
    "class Chat(protocol.Protocol):\n",
    "    def connectionMade(self):\n",
    "        self.color = colors.pop()\n",
    "        colors.insert(0, self.color)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "通过一个无限循序，我们把颜色依次加到用户名上，循环使用。如果你熟悉Python的`itertools`模块，你可以这么写："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "import itertools\n",
    "colors = itertools.cycle(('7F8C8D', 'C0392B', '2C3E50', '8E44AD', '27AE60'))\n",
    "def connectionMade(self):\n",
    "    self.color = colors.next()\n",
    "    # next(colors) in Python 3"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "现在，我们再把颜色添加到用户名上，很简单，就是`[b][color]Nickname[/\n",
    "color][/b]`。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "for t in transports:\n",
    "    if t is not self.transport:\n",
    "        t.write('[b][color={}]{}:[/color][/b] {}'.format(self.color, user, msg))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`main.py`里面的客户端也同时更新了。我们还要为当前发消息的用户增加一个固定的颜色："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "def send_msg(self):\n",
    "    msg = self.root.ids.message.text\n",
    "    self.conn.write('%s:%s' % (self.nick, msg))\n",
    "    self.root.ids.chat_logs.text += ('[b][color=2980B9]{}:[/color][/b] {}\\n'.format(self.nick, msg))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "然后，我们把聊天记录部件`ChatLabel`的`markup`属性设置为`True`：\n",
    "```yaml\n",
    "<ChatLabel@Label>:\n",
    "    markup: True\n",
    "```\n",
    "这样就可以了。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "##转义字符处理"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "和HTML一样，用户发送的消息可以会出现转义字符。比如BBCode之类的符号。要解决这个问题，我们可以用Kivy的`kivy.utils.escape_markup`来解决。但是还不是很完整，我们可以稍微调整一下："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "def esc_markup(msg):\n",
    "    return (msg.replace('&', '&amp;')\n",
    "            .replace('[', '&bl;')\n",
    "            .replace(']', '&br;'))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "这样所有的Kivy里面的转义字符转变成HTML字符替代，这样遇到这些字符时就会不会发生转义。在`server.py`文件里面，相应的代码需要改变："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "t.write('[b][color={}]{}:[/color][/b] {}'\n",
    "        .format(self.color, user,esc_markup(msg)))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "在`main.py`里面，实现是类似的："
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "collapsed": true
   },
   "outputs": [],
   "source": [
    "self.root.ids.chat_logs.text += (\n",
    "    '[b][color=2980B9]{}:[/color][/b] {}\\n'\n",
    "    .format(self.nick, esc_markup(msg)))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "现在bug修复了，用户可以安全的发从BBCode消息了。\n",
    "\n",
    "![chatlast](kbpic/4.7chatlast.png)\n",
    "\n",
    ">其实这类bug在互联网产品中很常见。类似于跨站脚本（cross-site\n",
    "scripting，XSS），可以造成比修改字体更恐怖的结果。\n",
    ">不要忘了净化所有产品的用户输入，因为用户一不小心用了命令行就麻烦了。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###后续任务"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "这些都只是开始，还有很多必须的事情没做。比如用户名唯一，没有历史记录和离线消息的支持。如果网络质量不好的话，消息总会丢失。\n",
    "\n",
    "但是更重要的是，这些问题都可以解决，我们已经有了产品原型。在产品开始阶段，原型是非常具有吸引力，先让轮子转起来，就有了动力；如果你因为好玩而编程，这种感觉更明显，因为你看到了一个可以使用的产品了（相反如果只是一块块代码，不能使用，那感觉很糟糕）。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "##总结"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "这一章我们看到了CS架构的应用开发（其实就网络编程）其实也不复杂。甚至底层的套接字代码也很容易搞定。\n",
    "\n",
    "当然，涉及到网络时会遇到很多灰色地带不容易搞定。包括高时延的处理，中断连接的恢复和多节点的数据同步等（尤其是点对点或多主机时，每一个机器只有一部分数据）。\n",
    "\n",
    "另一个目前比较新的网络问题就是政治方面的，政府已经开始实施互联网管制，包括出于安全的原因（比如，封杀恐怖主义网络资源）到完全无厘头（封杀教育网站像维基百科，主要的新闻网站或视频游戏网站）。这种连接问题会产生很高的间接伤害，如果CDN（content delivery network）挂了，很多使用CDN链接的网站就不能正常显示了。你懂的。\n",
    "\n",
    "但是，只要踏踏实实的坚持下去，一定可以克服重重困难把优质产品发布给客户。而且，Python丰富的特性可以减轻你的负担，本章的聊天app已经充分体现了这点：很多底层的细节都可以通过Kivy和Twisted轻松搞定。\n",
    "\n",
    "互联网领域有无限可能，永无止境。下一章我们继续网络相关的尝试，敬请期待。"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.5.1"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 0
}
